Skip to content

(sessions): follow /clear to the new transcript instead of listing it apart - #477

Open
abate wants to merge 2 commits into
devsuitup:mainfrom
abate:fix/clear-rekey
Open

abate wants to merge 2 commits into
devsuitup:mainfrom
abate:fix/clear-rekey

Conversation

@abate

@abate abate commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

/clear makes the CLI open a new jsonl under a new session id. Nothing in it names the session it replaced (no forkedFrom; every record carries the new id), so detectSessionTransitions() never matched it: the open terminal stayed on the old row and the new conversation appeared as a separate, unattached sidebar entry once its first prompt made it indexable.

Fix

The CLI rewrites ~/.claude/sessions/<pid>.json with the new sessionId on /clear (verified on live state files). cliSessionState.clearOwner(newId, ptyPid) finds the live pid naming newId and walks its /proc parent chain up to the PTY:

  • mine → re-key exactly like a fork (session-forked).
  • other → another process's file; ignored.
  • pending → state file not rewritten yet; rechecked for up to 60 s.
  • unknown (no /proc: macOS/Windows) → matched only when this is the sole live Claude PTY in the folder.

readNewSessionSignals() recognises the file by its first non-bookkeeping user record (classifyUserText). The renderer adds a pending "New session" row for the new id, since a /clear transcript is not indexed until its first prompt. The cleared conversation stays in the list as a past, resumable session.

Docs: .ai/contexts/cli-session-state.md → "Owner of a /clear transcript".

Tests

  • 8 new in test/session-transitions.test.js (mine / other / pending→mine / stale pending / unknown sole vs. two candidates / prompt-not-clear / post-fork clear / awaiting fork), 1 in test/cli-session-state.test.js (clearOwner verdicts).
  • Lint 0 errors. Full suite: failures only in the CLI-state canary (stale state file from CLI 2.1.259), sandbox-wrapper and test-pr tests — environment-bound, untouched files.

🤖 Generated with Claude Code

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 401e27e. Thanks for the PR. The idea is right: after /clear the new transcript should belong to the running session. The problems are in how the new transcript is attributed and how the session is re-keyed. The reviewer traced the paths below in the source. I re-read the code behind the first two; I have not run them.

Blocking

  1. An unrelated /clear can be attached to our terminal (session-transitions.js, the signals.cleared branch). matched is true when owner === 'unknown' && isSoleClaudeIn(folder). On Windows and macOS the ownership of a transcript cannot be established, so owner is unknown. If one managed session runs in a folder and the user runs claude outside the app in the same folder and clears it, that other session's new transcript is taken over by our terminal.
  2. Re-keying removes the id that in-flight triggers use (same block: activeSessions.delete(sessionId) then set(newId, ...)). /clear is one of the two commands the trigger watcher may send, and a chain resolves its target by session id at each step. After the /clear step the old id no longer exists, so the following steps abort even though the terminal is alive.
  3. A pending owner has no retry (same block). A file in the pending state is only rechecked when something else triggers a scan, and a partially written header can stay excluded from detection for good.
  4. Quit after /clear, before the first prompt (public/app.js). The saved new id is not in the index yet, so restore cannot find it.

Non-blocking

  • test/session-transitions.test.js (the awaiting-fork case) passes even though an older snapshot matcher causes a transition, so it does not pin what it says.
  • CHANGELOG.md: the entry needs the PR number as its suffix, (#477).
  • #482's archive state and the injected pending row may disagree.

The branch is also two commits behind main, with three overlapping files.

Six existing suites pass against the unchanged sources (81 tests, Node 24). Not verified: a real restart with SQLite, Node 20/22 with c8.

@jbr-sekoia

Copy link
Copy Markdown
Collaborator

Tested on Linux (Ubuntu, GNOME on Wayland), PR head 401e27e.

Setup. An isolated instance from the PR head, with a temp HOME, its own SWITCHBOARD_DATA_DIR, and CLAUDE*/GIT_* unset. The real claude is 2.1.292. The PR's test files pass: test/session-transitions.test.js and test/cli-session-state.test.js, 51/51.

/clear in a managed session (the Linux /proc path): works

  • A prompt, then /clear: the state file is rewritten to the new id, and the log shows owner=mine matched=true, then <old> → <new> (clear).
  • The active row becomes the new session, still holding its running PTY, and the cleared conversation stays as a past row.
  • The next prompt goes into the new transcript, and the row updates in place.
  • Cosmetic: the re-keyed row shows the old row's age ("2m ago") instead of "just now".

Blocking 1 (an unrelated /clear attached to our terminal): does not reproduce on Linux

  • Two managed sessions in the same folder, /clear in B. A logs owner=other matched=false, and only B is re-keyed.
  • claude in a plain shell outside Switchboard, in the same folder, then /clear. Both managed sessions log owner=other matched=false. Neither is re-keyed, and the external transcript is listed as its own row.
  • Same, with only one live managed session in the folder. It still logs owner=other matched=false: on Linux, /proc gives a definite owner, so the sole-PTY fallback is never reached.
  • Not covered. The owner=unknown path (macOS/Windows), where the review places the defect, cannot be exercised here.

Blocking 2 (a trigger chain with /clear aborts): reproduces

  • The chain was [{"command":"/clear"},{"command":"reply ok"}].
  • The re-key happens, and 70 ms later the watcher logs Session exited during chain step 0 submit verify.
  • The result is {"ok":false,"error":"session exited during wait","partial":true,"steps_completed":0}. Step 1 is never sent, although the terminal is alive with its PTY.

Blocking 3 (pending owner with no retry): not observed. In every live /clear, the state file was already rewritten when the new transcript was detected, so the verdict was never pending. That timing was not forced.

Blocking 4 (quit after /clear, before the first prompt): reproduces

  • /clear, no prompt, then quit and relaunch.
  • openWorkingSet held the new id. On relaunch the toast says "Not restored: is not in the index", and no row exists for it.

@devsuitup

Copy link
Copy Markdown
Owner

Blocking 1 of my review (an unrelated /clear attached to our terminal), reproduced on Windows at 401e27e. It complements the Linux test above, which could not exercise the owner=unknown path.

Driven in memory with the shipped session-transitions.js and cli-session-state.js and the fixture helpers of the existing tests, on Node 24:

  • One managed session A in the folder, an unrelated CLI B runs /clear, ancestry unknown: owner=unknown matched=true. The keys go from ["A"] to ["B"] and session-forked A B is sent: A is re-keyed to B's transcript.
  • Two managed sessions in the folder: owner=unknown matched=false, nothing re-keyed (the refusal works).
  • Control, B's descriptor is a descendant of A (injected ancestry): owner=mine matched=true, A is re-keyed. This is the legitimate case.
  • The real default parent-pid reader on this host: it returns null for a real child process and for an unrelated one, so on Windows ancestry is never established and owner=unknown is the normal path, not a corner case. A direct match on the PTY pid still gives mine.

So on Windows (and macOS, which shares the null reader), the single-managed-session fallback accepts any external /clear in the same folder once its transcript and live descriptor are detected. It needs a managed session and an independent CLI in the same project at the same time. The fix asked for stands: require positive ownership evidence, or leave the transcript unattached when ancestry cannot be established.

Not run: a real CLI or Electron, Node 20/22 with c8.

abate added 2 commits October 8, 2026 11:47
… it apart

/clear makes the CLI open a new jsonl under a new session id that names
nothing of the one it replaced, so fork detection never matched it: the
terminal stayed on the old row and the new conversation appeared as a
separate, unattached session. The CLI's state file switches to the new id
on /clear; resolve its pid up to the PTY's and re-key the session like a fork.
…ins on it

An owner that cannot be established (no /proc: macOS, Windows) was matched
when one Claude PTY ran in the folder, so a claude run outside the app could
be taken over; it is now never matched. A re-key kept no trace of the old id,
so a trigger chain that sent /clear lost its terminal: the trigger context
now resolves ids through the re-keys. A pending owner, or a new file whose
first records are not written yet, is rechecked a second later while fresh,
instead of waiting for another change in the folder. The snapshot-only fork
matcher no longer takes a /clear file. A session with no transcript yet is
left out of the saved set, which is saved again once its first prompt makes
it real.
@abate

abate commented Oct 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks to both of you, and thanks @devsuitup for the Windows reproduction of point 1. Rebased on current main (eaf59ff); the conflicts with #374/#487 in cli-session-state.js, its test and its doc were additive. The fixes are in 4a670ad, on top of the rebased original commit.

Blocking

  1. An unrelated /clear attached to our terminal.
    • The sole-PTY fallback is gone. unknown is never matched: the file is recorded and not retried. A /clear is followed only on positive evidence, an ancestry walk to the PTY or the PTY's own pid being the CLI's (owner === 'mine').
    • As your reproduction shows, the parent reader returns null on macOS and Windows, so there /clear is followed only when the PTY runs the CLI directly. Otherwise the new conversation is listed apart, as before this PR. cli-session-state.md says so.
    • clearOwner also compares ids case-insensitively now, like the rest of the module since (agents): follow-ups from the #374 review #487.
    • Test: with unknown and a single Claude PTY in the folder, nothing is re-keyed, no session-forked is sent, and the file is recorded.
  2. Re-keying breaks trigger chains.
    • session-transitions.js keeps oldId → newId on each re-key, fork or /clear, and exports currentSessionId, which follows the hops. createTriggerContext takes resolveSessionId, which main sets to currentSessionId, and resolves every lookup through it: getPtyForSession, isSessionBusy, getComposerState, getTranscriptTurn and getCliStatus. The old id reaches the same terminal, and the CLI status is read under the id the CLI now writes.
    • Test: two /clears in a row; currentSessionId('old-id') is the newest id, getPtyForSession('old-id') returns the live PTY, and getCliStatus is asked for the newest id. Described in trigger-watcher.md, "Re-keyed sessions".
  3. A pending owner has no retry.
    • A pending verdict on a fresh file schedules one recheck of the folder a second later (scheduleRecheck, at most one per folder at a time), until the 60 s window ends. The same applies to a new file whose first records are not written yet, the partial-header case, while it is under 60 s old. Older ones wait for the next change in the folder, as before.
    • Tests: a pending owner is matched by the scheduled recheck alone; a partial file is rechecked and then matched; an old partial file schedules nothing.
  4. Quit after /clear, before the first prompt.
    • A session with no transcript yet, still a pending row, is now left out of the saved set: nothing exists to resume, and the next start no longer reports it as "not in the index". When loadProjects finds its transcript and drops the pending row of an open session, the set is saved again with it.
    • This also covers a session started with + and never prompted, which had the same problem. docs/session-restore.md has a short section on it.
    • Tests in test/clear-pending-persist.test.js, through the shipped persistWorkingSet and loadProjects.

Non-blocking

Other changes

  • Two existing harnesses that load persistWorkingSet declare pendingSessions: restore-live-elsewhere and restore-pending-persist.
  • cli-session-state.md covers the unknown rule, the recheck, the alias and the snapshot matcher.

Each fix was removed in turn, and each removal fails a test: the unknown fallback, the alias record, the PTY and status resolution, the recheck, the partial-file recheck and its age limit, the snapshot exclusion, the persist exclusion and the persist on becoming real.

On the full suite, the only failures are the CLI-state canary, test-pr and sandbox-wrapper tests, which fail the same way on main in my sandboxed environment. Not run: a live /clear in the app, and Windows or macOS.

@abate
abate requested a review from devsuitup October 8, 2026 09:55

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 4a670ad, rebased on current main. Thanks: the unknown-owner fallback no longer attaches a stranger's transcript, the missing-id lookup and the awaiting-fork assertion are fixed, the pending-owner retry works, the changelog suffix is there and the docs are updated. 176 tests in 12 related suites pass locally on Node 24, and the reviewer reproduced the findings below with the shipped modules. Four points block; three of them were already reproduced on Linux in the maintainer's test of 401e27e.

Blocking

  1. A header-only transcript is marked known before /clear appears, so it never transitions (session-transitions.js ~495, the final knownJsonlFiles update). A flush that reads only the initial mode/caveat records sees cleared === false; the file is not put in emptyFiles, so it joins knownJsonlFiles and the /clear record appended later never reconsiders it. Keep a new file with a header and no command record eligible (as the pending-owner case does) until it shows a first user turn or goes stale. Test: write the header, flush, append the /clear record, flush again, and expect the transition.
  2. Quitting after /clear and before the first prompt loses the tab on restore (public/app.js ~185). persistWorkingSet saves the new id; the new transcript is bookkeeping-only, so it is never indexed and the restore planner reports "not in the index". Reproduced on Linux: "Not restored: is not in the index". Persist enough to restore the cleared session independently of indexability (or keep the old id as the restorable target until a prompt exists). Test: clear, persist, restart the planner with that set, and expect the session restored.
  3. An awaiting fork accepts another session's snapshot prefix (session-transitions.js ~450). A session waiting for its fork matches any new transcript that has only snapshots and no /clear marker, before any ownership evidence arrives. Require the forkFrom or ownership evidence before matching, or wait for the marker.
  4. The old and the new id take different trigger locks (trigger-context.js ~44, trigger-watcher.js ~1843). sessionLocks is keyed by the raw session id, but both ids reach the same PTY after a re-key, so triggers aimed at the old and the new id can interleave their writes. Key the lock by the PTY (or resolve the id through rekeyed before locking). The chain that aborts at step 0 on Linux ("session exited during chain step 0 submit verify") needs the in-flight target followed through the transition as well; please cover it with a test that runs [/clear, reply] through the shipped watcher.

Non-blocking

  • Pending-row injection in the renderer bypasses the backend archive filtering (public/app.js ~1171): an archived cleared session can reappear.
  • An original trigger id stops resolving after 33 clears (session-transitions.js ~34, the bounded map).
  • Explanatory comments at session-transitions.js ~31 and ~449 go beyond the one-line pointer allowed in code.
  • test/clear-pending-persist.test.js (~43) supplies the pending rows by hand, so the new renderer callback branch is not pinned.

Not run: a live /clear chain, a SQLite restart, macOS, Node 20/22 with c8, lint, fresh CI. CI on this head is not approved yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants